#!/usr/bin/env python
# -*- coding: UTF-8 -*-
'''
@Project ：V2 
@File    ：gas_shop_ga.py
@IDE     ：PyCharm 
@Author  ：郭星
@Date    ：2025/9/23 10:43 
'''
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Gas Mixing -> QC Scheduling with Batching & Multi-device Coverage
-----------------------------------------------------------------
特性：
- 配气(Mix)阶段：同配方(组分+浓度)可合批，也可单做；并行配气机；序列相关setup(换型/吹扫)
- 分析(QC)阶段：设备异构（各设备可测组分不同）；同一产品可
    (a) 并行：分给多台设备同时测各自覆盖的组分；或
    (b) 串联：按设备链依次测
  各设备可组批（批容量、批时长函数）

- GA 优化：合批/单做选择、配气批的排序、QC的并/串选择与设备序列、设备内组批排序
- 目标：加权（Cmax + 总迟期 + 配气setup + QCsetup）

Author: you
"""

import random
import numpy as np
from dataclasses import dataclass
from typing import List, Dict, Set, Tuple, Optional

try:
    import matplotlib.pyplot as plt
    HAS_MPL = True
except Exception:
    HAS_MPL = False


# ---------------------- 基本数据结构 ----------------------

@dataclass
class Recipe:  # 用于定义“配方键”——同键可合批
    family: str          # 比如 'N2' / 'Air' / 'Inert'
    components: Tuple[Tuple[str,float], ...]  # (组分, 浓度%) 的有序元组（作为精确键）

    def key(self) -> Tuple[str, Tuple[Tuple[str,float], ...]]:
        return (self.family, self.components)

@dataclass
class Order:
    id: int
    recipe: Recipe
    qty: int                  # 瓶数
    due: float                # 交期（小时）

@dataclass
class MixMachineConf:
    id: int
    name: str
    rate_qty_per_h: float     # 配气速度：瓶/小时（合批时按 sum(qty)/rate 得加工时间）
    setup_time_map: Dict[Tuple[Tuple[str,Tuple[Tuple[str,float],...]], Tuple[str,Tuple[Tuple[str,float],...]]], float]
                              # setup_time[(from_recipe_key, to_recipe_key)] -> hours

@dataclass
class QCDevice:
    id: int
    name: str
    caps: Set[str]            # 能测的组分集合
    base_time: float          # 批的固定时间
    per_sample_time: float    # 批中每个样本附加时间（线性）
    setup_time: float         # 不同“方法签名”切换的setup（简化）

@dataclass
class Task:  # 仅用于甘特图
    stage: str             # "MIX" or "QC"
    machine_name: str
    start: float
    end: float
    label: str             # 文本，例如 "Batch#3" 或 "S7 on E1"


# ---------------------- 车间与参数 ----------------------

class Plant:
    def __init__(self,
                 mix_machines: List[MixMachineConf],
                 qc_devices: List[QCDevice],
                 max_mix_batch_qty: int = 600,
                 qc_batch_cap: int = 6):
        self.mix_machines = mix_machines
        self.qc_devices = qc_devices
        self.max_mix_batch_qty = max_mix_batch_qty
        self.qc_batch_cap = qc_batch_cap

    # 配气阶段：加工时间（合批：按总瓶数）
    def mix_proc_time(self, total_qty: int, machine: MixMachineConf) -> float:
        return total_qty / machine.rate_qty_per_h

    # 配气阶段：序列相关setup（以 recipe key 为粒度）
    def mix_setup_time(self, prev_key, cur_key, machine: MixMachineConf) -> float:
        if prev_key is None or prev_key == cur_key:
            return 0.0
        return machine.setup_time_map.get((prev_key, cur_key), 0.3)  # 缺省值

    # QC 阶段：方法签名（简化为“样品在该设备上要测的组分子集”）
    def qc_signature(self, req_comp: Set[str], dev: QCDevice) -> Tuple[str, Tuple[str,...]]:
        sub = tuple(sorted(req_comp & dev.caps))
        return ("SIG", sub)

    # QC 阶段：某设备上一个“批”的处理时间（线性批时）
    def qc_batch_time(self, batch_size: int, dev: QCDevice) -> float:
        if batch_size <= 0: return 0.0
        return dev.base_time + dev.per_sample_time * batch_size


# ---------------------- 数据生成（示例） ----------------------

def build_demo(seed=7):
    random.seed(seed); np.random.seed(seed)

    # 设备：并行配气台 2 台；分析设备 3 台（互补能力）
    families = ["N2","Inert","Air"]  # family 可用于setup矩阵
    mix_machines = []
    setup_map_template = {}
    # 简单的 family-based setup：同家族 0.05h，跨家族 0.3~0.6h
    for f1 in families:
        for f2 in families:
            base = 0.05 if f1==f2 else (0.3 if {f1,f2}=={"N2","Inert"} else 0.6)
            # recipe key 里含完整浓度，这里退化为只看 family 的默认 setup
            setup_map_template[( (f1,()), (f2,()) )] = base

    # 为每台配气机复制一份映射（真实情况应按 recipe key 具体配置；这里简化）
    for mid in range(2):
        setup = {}
        for (k1,k2), v in setup_map_template.items():
            setup[(k1,k2)] = v
        mix_machines.append(
            MixMachineConf(
                id=mid, name=f"Mix{mid+1}",
                rate_qty_per_h=180.0 if mid==0 else 150.0,
                setup_time_map=setup
            )
        )

    qc_devices = [
        QCDevice(0, "E1", {"O2","CO2","CO"}, base_time=0.25, per_sample_time=0.10, setup_time=0.15),
        QCDevice(1, "E2", {"CH4","C2H6","CO2"}, base_time=0.30, per_sample_time=0.12, setup_time=0.20),
        QCDevice(2, "E3", {"H2","O2","N2"},  base_time=0.20, per_sample_time=0.08, setup_time=0.12),
    ]

    # 订单数据：生成 20 个订单，配方不同（用 (组分, 浓度%) 作为 recipe.components）
    # 例：N2 中 2% O2 + 1% CO2；N2 中 1.5% CH4；Inert 中 5% H2 等
    def mk_recipe(family: str, comps: Dict[str,float]) -> Recipe:
        comps_t = tuple(sorted(((k, round(v,3)) for k,v in comps.items()), key=lambda x:x[0]))
        return Recipe(family, comps_t)

    candidates = [
        mk_recipe("N2",    {"O2":2.0, "CO2":1.0}),
        mk_recipe("N2",    {"CH4":1.5}),
        mk_recipe("Air",   {"CO":0.5}),
        mk_recipe("Inert", {"H2":5.0}),
        mk_recipe("N2",    {"O2":1.0}),
        mk_recipe("N2",    {"CO2":3.0}),
        mk_recipe("Inert", {"H2":3.0, "CO2":1.0}),
        mk_recipe("N2",    {"CH4":0.8, "CO2":0.5}),
    ]

    orders: List[Order] = []
    for i in range(20):
        r = random.choice(candidates)
        qty = random.randint(60, 220)  # 瓶
        due = random.uniform(8.0, 30.0)
        orders.append(Order(i, r, qty, due))

    plant = Plant(mix_machines, qc_devices,
                  max_mix_batch_qty=600,   # 配气合批容量上限（瓶数）
                  qc_batch_cap=6)          # 每台分析仪一次同批最多样品数
    return plant, orders


# ---------------------- 解码：从染色体到时间表 ----------------------

class Decoder:
    """
    染色体（个体）结构（简化但可扩展）：
      - gene_split[n] ∈ {0,1}：订单 n 是否“强制单做”（1=单做；0=允许与同配方合批）
      - mix_seq: 配气批的排序“偏好键”（解码时按(家族,总量,随机扰动)构造序列），也可细化为显式批序列
      - qc_mode[n] ∈ {0,1}：0=并行覆盖；1=串联覆盖
      - dev_seq[d]: 每台分析仪上的样品顺序（用样品索引的随机排列；解码时会筛到真正需要此设备的样品）
    """
    def __init__(self, plant: Plant, orders: List[Order]):
        self.P = plant
        self.orders = orders
        self.N = len(orders)
        self.D = len(plant.qc_devices)

    # ---------- 配气阶段：根据 gene_split 决定合批/单做，并构建批列表 ----------
    def build_mix_batches(self, gene_split: np.ndarray) -> List[List[int]]:
        """
        返回：批列表，每批是订单索引列表（同配方，且总量不超 cap）
        合批规则：同配方（完全相同 components + family），gene_split=0 的订单允许合批；
                 gene_split=1 的订单独自成批。
        """
        cap = self.P.max_mix_batch_qty
        # 先按 recipe.key 聚类
        by_key: Dict[Tuple, List[int]] = {}
        for i,o in enumerate(self.orders):
            by_key.setdefault(o.recipe.key(), []).append(i)

        batches: List[List[int]] = []
        for key, idxs in by_key.items():
            # 先把所有“强制单做”的订单单独成批
            singles = [i for i in idxs if gene_split[i]==1]
            for i in singles:
                batches.append([i])
            # 余者按总量贪心合批
            rest = [i for i in idxs if gene_split[i]==0]
            # 让大单优先放（减少超容）
            rest.sort(key=lambda i: self.orders[i].qty, reverse=True)
            cur = []
            cur_qty = 0
            for i in rest:
                q = self.orders[i].qty
                if cur_qty + q <= cap:
                    cur.append(i); cur_qty += q
                else:
                    if cur:
                        batches.append(cur)
                    cur = [i]; cur_qty = q
            if cur:
                batches.append(cur)
        return batches

    # ---------- 配气阶段排程（并行机器 + 序列相关setup） ----------
    def schedule_mixing(self, batches: List[List[int]]) -> Tuple[Dict[int,float], List[Task], float]:
        """
        输入：batches = [[订单...], ...] 每个元素是一批订单
        输出：
          mix_end_of_order: 每个订单在配气阶段的完工时间（用于后续QC就绪时间）
          mix_tasks: 甘特任务（Task）
          total_setup_mix: 配气setup总时间
        机台分配策略：简单的“哪台先空就派哪台”，序列相关setup取决于“配方family”
        批加工时间：sum(qty)/rate
        """
        machines = self.P.mix_machines
        M = len(machines)
        # 为了稳定、尽量减少setup：让批次按 family 分块（也可直接由 GA 生成显式排序）
        def batch_key(b):
            fam = self.orders[b[0]].recipe.family
            total = sum(self.orders[i].qty for i in b)
            return (fam, -total, random.random())
        batches_sorted = sorted(batches, key=batch_key)

        ready = [0.0]*M
        last_key = [None]*M
        mix_end_of_order: Dict[int,float] = {}
        tasks: List[Task] = []
        total_setup = 0.0

        for b in batches_sorted:
            # 选择最早可完工的机
            best = None
            for m in range(M):
                mach = machines[m]
                fam = self.orders[b[0]].recipe.family
                # 用 family-only 的setup矩阵（我们把具体 key 折叠为 (family,()) 缩减维度）
                prev_k = last_key[m] if last_key[m] is not None else None
                cur_k = (fam, ())
                setup = self.P.mix_setup_time(prev_k, cur_k, mach)
                proc = self.P.mix_proc_time(sum(self.orders[i].qty for i in b), mach)
                st = ready[m] + setup
                en = st + proc
                if (best is None) or (en < best[0]):
                    best = (en, m, st, proc, setup, cur_k)
            en, m, st, proc, setup, cur_k = best
            ready[m] = en
            last_key[m] = cur_k
            total_setup += setup
            tasks.append(Task("MIX", machines[m].name, st, en, f"Batch({','.join('S'+str(i) for i in b)})"))
            for i in b:
                mix_end_of_order[i] = en  # 假设同批订单在 en 完成

        return mix_end_of_order, tasks, total_setup

    # ---------- QC 阶段：设备覆盖 + 并/串选择 + 设备内组批与排程 ----------
    def greedy_min_cover(self, req: Set[str]) -> List[int]:
        """用贪心集覆盖，选出能覆盖 req 的设备集合（最小设备数优先）。"""
        remaining = set(req)
        picked = []
        devs = self.P.qc_devices
        while remaining:
            best = None
            for d,dev in enumerate(devs):
                gain = len(dev.caps & remaining)
                if gain>0:
                    score = gain / (1e-6 + dev.base_time + dev.per_sample_time)  # 简单效益
                    if (best is None) or (score > best[0]):
                        best = (score, d, gain)
            if best is None:
                break  # 覆盖不了（现实里应报错或回退）
            _, d, _ = best
            picked.append(d)
            remaining -= devs[d].caps
        return picked

    def schedule_qc(self,
                    mix_end_of_order: Dict[int,float],
                    qc_mode: np.ndarray,         # 0=并行 1=串联
                    qc_dev_seq: List[List[int]]  # 每台设备的样品序（解码时会筛）
                    ) -> Tuple[float, float, List[Task]]:
        """
        返回：(Cmax, total_setup_qc, qc_tasks)
        流程：
          - 对每个样品：用最小覆盖贪心选择设备集合 picked_devs
          - 并/串行：
              并行：每台设备把该样品加入待测队列；样品放行时间 = 所有涉及设备的完成最大值
              串联：按设备id升序形成链；依次在这些设备上安排（受前一步完成时间约束）
          - 设备内组批：按 qc_dev_seq[d] 的顺序把需要这台设备的样品凑批(<=cap)，批时=base+per*批量
          - setup：按“方法签名”变化统计
        """
        devs = self.P.qc_devices
        D = len(devs)
        cap = self.P.qc_batch_cap
        N = len(self.orders)

        # 为每个样品确定设备集合与顺序
        sample_devs: Dict[int, List[int]] = {}
        for i, o in enumerate(self.orders):
            req = {comp for comp,_ in o.recipe.components}
            picked = self.greedy_min_cover(req)
            if len(picked)==0:
                picked = []  # 不可覆盖（用不到则跳过）
            if qc_mode[i]==0:
                # 并行：顺序无关
                sample_devs[i] = picked
            else:
                # 串联：用设备 id 升序形成链（也可在 GA 中编码具体链序）
                sample_devs[i] = sorted(picked)

        # 构建每台设备的待测样品列表（保持 qc_dev_seq 的相对顺序）
        list_per_dev: List[List[int]] = [[] for _ in range(D)]
        picked_set_per_dev = [set() for _ in range(D)]
        need_on_dev = [set() for _ in range(D)]
        for i, dev_list in sample_devs.items():
            for d in dev_list:
                need_on_dev[d].add(i)
        for d in range(D):
            for sidx in qc_dev_seq[d]:
                if sidx in need_on_dev[d] and sidx not in picked_set_per_dev[d]:
                    list_per_dev[d].append(sidx)
                    picked_set_per_dev[d].add(sidx)

        # 设备内组批：按序列凑 cap
        batches_per_dev: List[List[List[int]]] = []
        for d in range(D):
            seq = list_per_dev[d]
            cur=[]; blist=[]
            for i in seq:
                cur.append(i)
                if len(cur)>=cap:
                    blist.append(cur); cur=[]
            if cur: blist.append(cur)
            batches_per_dev.append(blist)

        # 设备排程：记录每台设备 ready 与最后方法签名；样品在设备上的开始/完工，用于并/串同步
        ready = [0.0]*D
        last_sig: List[Optional[Tuple[str,Tuple[str,...]]]] = [None]*D
        tasks: List[Task] = []
        total_setup = 0.0

        # 串联时，需要维护样品上一步完工时间
        sample_prev_end = {i: mix_end_of_order[i] for i in range(N)}  # 从配气完工开始可进 QC
        sample_dev_end = {}  # (i,d)->end

        # 先做并行近似排程：不考虑串联依赖；随后若串联则强制批开始>=样品上一步完工
        # 为保证串联依赖，我们在排批时计算该批中每个样品的“可开始时间”，取最大值作批的 earliest start
        for d in range(D):
            dev = devs[d]
            for b in batches_per_dev[d]:
                if len(b)==0: continue
                # 批内每个样品在该设备上的方法签名（用于统计setup，仅按批首与上一批比较）
                # 这里用“批内第一个样品”的签名来代表该批的方法（可扩展为更细）
                first_sig = self.P.qc_signature({comp for comp,_ in self.orders[b[0]].recipe.components}, dev)
                setup = 0.0 if last_sig[d] is None or last_sig[d]==first_sig else dev.setup_time

                # 批可开始时间：设备ready 与 “若串联则各样品上一步完工” 取最大
                if any(qc_mode[i]==1 and d in sample_devs[i] for i in b):
                    # 串联样品：需要保证在此设备前置设备已完成
                    # 对于每个样品 i，找到它在 sample_devs[i] 中该设备 d 的前一个设备的完工时间
                    est_list=[]
                    for i in b:
                        if qc_mode[i]==0:
                            est_list.append(mix_end_of_order[i])
                        else:
                            # 串联：其上一设备是 dev_list 中 d 的前一个；若没有，则 mix_end
                            chain = sample_devs[i]
                            pos = chain.index(d) if d in chain else -1
                            if pos<=0:
                                est_list.append(mix_end_of_order[i])
                            else:
                                prev_dev = chain[pos-1]
                                est_list.append(sample_dev_end.get((i,prev_dev), mix_end_of_order[i]))
                    earliest_by_chain = max(est_list) if est_list else 0.0
                else:
                    earliest_by_chain = 0.0

                st = max(ready[d] + setup, earliest_by_chain)
                pt = self.P.qc_batch_time(len(b), dev)
                en = st + pt
                total_setup += setup
                last_sig[d] = first_sig
                ready[d] = en
                tasks.append(Task("QC", dev.name, st, en, f"Batch[{','.join('S'+str(i) for i in b)}]"))

                # 记录批内每个样品在此设备的完成时间
                for i in b:
                    sample_dev_end[(i,d)] = en
                    # 若串联，更新其“上一环节完工”
                    if qc_mode[i]==1:
                        # 链的当前设备完成，供后续设备用
                        pass

        # 样品放行时间：并行 = 涉及设备完工最大值；串联 = 链最后设备完工
        release = np.zeros(self.N)
        for i in range(self.N):
            devs_i = sample_devs[i]
            if not devs_i:
                release[i] = mix_end_of_order[i]  # 没检到设备（理论上不会发生）
            else:
                release[i] = max(sample_dev_end.get((i,d), mix_end_of_order[i]) for d in devs_i)

        Cmax = float(np.max(release)) if len(release)>0 else 0.0
        return Cmax, total_setup, tasks


# ---------------------- GA 优化器 ----------------------

class GA:
    """
    个体：
      - gene_split[N]: 订单是否强制单做（1=单做；0=允许合批）
      - qc_mode[N]:    0=并行覆盖；1=串联覆盖
      - qc_dev_seq[D]: 每个设备的样品顺序（样品索引的全排列）
    说明：
      - 配气批的“具体分组与排序”由解码器根据 gene_split 贪心生成，并以 family/总量排序，
        也可以扩展把“批序列”作为显式基因。
    """
    def __init__(self, plant: Plant, orders: List[Order],
                 pop=60, gens=200, pc=0.9, pm=0.3, elite=None, seed=0):
        self.P = plant; self.orders = orders
        self.N = len(orders); self.D = len(plant.qc_devices)
        self.pop = pop; self.gens = gens; self.pc = pc; self.pm = pm
        self.elite = elite if elite is not None else max(2, pop//10)
        random.seed(seed); np.random.seed(seed)
        self.dec = Decoder(plant, orders)

    def rand_ind(self):
        gene_split = np.random.randint(0,2,size=self.N,dtype=int)  # 0/1
        qc_mode = np.random.randint(0,2,size=self.N,dtype=int)     # 0/1
        qc_dev_seq = [list(np.random.permutation(self.N)) for _ in range(self.D)]
        return (gene_split, qc_mode, qc_dev_seq)

    def cx(self, p1, p2):
        (a1,a2,a3) = p1; (b1,b2,b3) = p2
        # 基因1/2：单点交叉
        cut1 = np.random.randint(1, self.N)
        cut2 = np.random.randint(1, self.N)
        c1_1 = np.concatenate([a1[:cut1], b1[cut1:]])
        c2_1 = np.concatenate([b1[:cut1], a1[cut1:]])
        c1_2 = np.concatenate([a2[:cut2], b2[cut2:]])
        c2_2 = np.concatenate([b2[:cut2], a2[cut2:]])
        # 基因3：序列OX
        def OX(x,y):
            n=len(x); i,j=sorted(np.random.choice(n,2,replace=False))
            child=[None]*n; child[i:j]=x[i:j]; pos=j
            for v in y:
                if v not in child:
                    if pos>=n: pos=0
                    child[pos]=v; pos+=1
            return child
        c1_3=[]; c2_3=[]
        for d in range(self.D):
            c1_3.append(OX(a3[d], b3[d]))
            c2_3.append(OX(b3[d], a3[d]))
        return (c1_1, c1_2, c1_3), (c2_1, c2_2, c2_3)

    def mutate(self, ind, pm1=0.2, pm2=0.2, pm3=0.3):
        g1,g2,g3 = ind
        if np.random.rand()<pm1:
            pos=np.random.randint(self.N); g1[pos]=1-g1[pos]
        if np.random.rand()<pm2:
            pos=np.random.randint(self.N); g2[pos]=1-g2[pos]
        if np.random.rand()<pm3:
            d=np.random.randint(self.D)
            if len(g3[d])>=2:
                if np.random.rand()<0.5:
                    i,j=sorted(np.random.choice(len(g3[d]),2,replace=False))
                    g3[d][i],g3[d][j]=g3[d][j],g3[d][i]
                else:
                    i=np.random.randint(len(g3[d]))
                    val=g3[d].pop(i)
                    j=np.random.randint(len(g3[d])+1)
                    g3[d].insert(j,val)
        return (g1,g2,g3)

    def fitness(self, ind):
        gene_split, qc_mode, qc_dev_seq = ind
        # 配气：合批->排程
        batches = self.dec.build_mix_batches(gene_split)
        mix_end, mix_tasks, setup_mix = self.dec.schedule_mixing(batches)
        # QC：排程
        Cmax_qc, setup_qc, qc_tasks = self.dec.schedule_qc(mix_end, qc_mode, qc_dev_seq)

        # KPI：Cmax, tardiness, setups
        release = Cmax_qc  # 所有样品的放行最大值近似为全局Cmax
        tard = 0.0
        # 近似：用各订单的 QC 就绪时间 + 隐含完成（简化）来计算迟期，这里我们只用 release 近似与 due 的差
        # 可扩展：逐订单回溯各自 release_i
        for o in self.orders:
            tard += max(0.0, release - o.due)
        kpis = dict(Cmax=release, Tardiness=tard, MixSetup=setup_mix, QCSetup=setup_qc)

        # 目标：加权和（可调）
        obj = 1.0*release + 1.0*tard + 0.5*setup_mix + 0.3*setup_qc
        return obj, kpis, (mix_tasks+qc_tasks)

    def tournament(self, pop_fit, k=2):
        idx = np.random.choice(len(pop_fit), k, replace=False)
        best = min(idx, key=lambda i: pop_fit[i][1])
        return pop_fit[best][0]

    def run(self, verbose=True):
        pop = [self.rand_ind() for _ in range(self.pop)]
        pf=[]
        for ind in pop:
            obj,kpis,tasks = self.fitness(ind)
            pf.append((ind,obj,kpis,tasks))
        best = min(pf, key=lambda x:x[1])
        hist=[best[1]]

        for g in range(1, self.gens+1):
            pf.sort(key=lambda x:x[1])
            elites = pf[:self.elite]
            new_pop=[e[0] for e in elites]
            while len(new_pop)<self.pop:
                p1 = self.tournament(pf,2)
                p2 = self.tournament(pf,2)
                c1,c2 = p1,p2
                if np.random.rand()<self.pc:
                    c1,c2 = self.cx(p1,p2)
                if np.random.rand()<self.pm:
                    c1=self.mutate(c1)
                if np.random.rand()<self.pm:
                    c2=self.mutate(c2)
                new_pop.extend([c1,c2])
            pop=new_pop[:self.pop]
            pf=[]
            for ind in pop:
                obj,kpis,tasks = self.fitness(ind)
                pf.append((ind,obj,kpis,tasks))
            cur_best=min(pf, key=lambda x:x[1])
            if cur_best[1]<best[1]:
                best=cur_best
            hist.append(best[1])
            if verbose and g%20==0:
                print(f"[GA] Gen {g:4d} Best={best[1]:.3f}")

            # 单调化（精英保留已基本保证；这里只是记录best轨迹）
        return best[0], best[1], best[2], hist, best[3]


# ---------------------- 可视化 ----------------------

def plot_convergence(hist):
    if not HAS_MPL: return
    import matplotlib.pyplot as plt
    plt.figure(figsize=(6,4))
    plt.plot(hist, lw=2)
    plt.xlabel("Generation"); plt.ylabel("Best-so-far Objective")
    plt.title("GA Convergence")
    plt.grid(True, alpha=0.3)
    plt.tight_layout(); plt.show()

def plot_gantt(tasks: List[Task], title="Gantt"):
    if not HAS_MPL or not tasks: return
    # 分行显示：先按 stage，再按 machine
    rows = {}
    for t in tasks:
        key = (t.stage, t.machine_name)
        rows.setdefault(key, []).append(t)
    # 排序
    keys = sorted(rows.keys(), key=lambda k: (k[0], k[1]))
    import matplotlib.pyplot as plt
    fig, ax = plt.subplots(figsize=(12, max(3, 0.6*len(keys)+2)))
    ytick=[]; ylab=[]
    y=0
    for key in keys:
        row = sorted(rows[key], key=lambda x:x.start)
        for t in row:
            ax.barh(y, t.end-t.start, left=t.start, height=0.8)
            ax.text((t.start+t.end)/2, y, t.label, ha='center', va='center', color='white', fontsize=8)
        ytick.append(y); ylab.append(f"{key[0]}-{key[1]}")
        y+=1
    ax.set_yticks(ytick); ax.set_yticklabels(ylab)
    ax.set_xlabel("Time (h)"); ax.set_title(title)
    ax.grid(axis='x', alpha=0.3)
    plt.tight_layout(); plt.show()


# ---------------------- 主入口 ----------------------

def main():
    plant, orders = build_demo(seed=8)
    ga = GA(plant, orders, pop=80, gens=240, pc=0.9, pm=0.35, elite=10, seed=1)
    best_ind, best_obj, kpis, hist, tasks = ga.run(verbose=True)

    print("\n== KPIs ==")
    for k,v in kpis.items():
        print(f"{k:>10s}: {v:.3f}")
    print(f"{'Objective':>10s}: {best_obj:.3f}")

    # 展示部分合批/并串模式（前10个）
    gene_split, qc_mode, qc_dev_seq = best_ind
    print("\nOrders: force_single(1)/allow_batch(0) & QC mode (0=parallel,1=serial):")
    for i,o in enumerate(orders[:10]):
        print(f"Order S{i}: split={gene_split[i]}  qc_mode={qc_mode[i]}  due={o.due:.1f}h  qty={o.qty}")

    plot_convergence(hist)
    plot_gantt(tasks, title="Mix & QC Gantt (Batched + Multi-device Coverage)")

if __name__ == "__main__":
    main()
